jellyfin-tools/jellyfin-year-review/web/src/routes/[...path]/+page.svelte
jellyfin-year-review/web/src/routes/[...path]/+page.svelteRaw
1<script>
2 import { onMount } from 'svelte';
3 import { t, message, locale } from '../../lib/i18n.js';
4 import Poster from '../../lib/Poster.svelte';
5 import PlatformBreakdown from '../../lib/PlatformBreakdown.svelte';
6 import Chart from '../../lib/Chart.svelte';
7 import RadarChart from '../../lib/RadarChart.svelte';
8 import ViewingTimeline from '../../lib/ViewingTimeline.svelte';
9 import RankingPosition from '../../lib/RankingPosition.svelte';
10 import Confetti from '../../lib/Confetti.svelte';
11
12 let user = $state(null), years = $state([]), year = $state(''), review = $state(null);
13 let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');
14 let username = $state(''), password = $state(''), slide = $state(0);
15 let swipeDirection = $state(0);
16 let touch = null, request = 0;
17 let authMode = $state('jellyfin');
18 const chapters = [
19 { id: 0, name: t("Your viewing recap") },
20 { id: 1, name: t("Your watch time"), availability: 'exactWatchTime' },
21 { id: 2, name: t("Movies watched") },
22 { id: 3, name: t("TV shows watched") },
23 { id: 4, name: t("Unfinished movies"), availability: 'completion' },
24 { id: 14, name: t("Shows you've watched are from platforms...") },
25 { id: 5, name: t("Your top genres") },
26 { id: 6, name: t("Viewing by hour") },
27 { id: 7, name: t("Viewing by weekday") },
28 { id: 8, name: t("Viewing by month") },
29 { id: 9, name: t("Your devices and apps") },
30 { id: 10, name: t("Your longest viewing session"), availability: 'marathon' },
31 { id: 11, name: t("Your viewing profile"), availability: 'personality' },
32 { id: 12, name: t("It's not a competition... but this is your watch-time ranking"), availability: 'ranking' },
33 { id: 13, name: t("Your year in summary") }
34 ];
35 // Navigation and chapter numbering follow only the available cards for this year.
36 let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false));
37 let card = $derived(cards[slide]?.id);
38 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map(value => t(value));
39 const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(value => t(value));
40 const count = (n) => new Intl.NumberFormat(locale).format(n);
41 const hours = (n) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(n / 3600);
42 const duration = (n) => t("{hours} h", { hours: hours(n) });
43 const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea');
44
45 async function api(path, options = {}) {
46 let res, body;
47 try {
48 res = await fetch(path, { credentials: 'same-origin', ...options });
49 body = await res.json();
50 } catch {
51 throw new Error(t("The request could not be completed."));
52 }
53 if (!res.ok) {
54 if (res.status === 401 && path !== '/api/login') { user = null; review = null; }
55 throw Object.assign(new Error(message(body.error) || t("The request could not be completed.")), { status: res.status });
56 }
57 return body;
58 }
59 async function loadReview() {
60 if (!year) return;
61 const current = ++request;
62 loading = true; error = ''; review = null; slide = 0; swipeDirection = 0;
63 try {
64 const data = await api(`/api/review?year=${encodeURIComponent(year)}`);
65 if (current !== request) return;
66 if (data.schemaVersion !== 1) throw new Error(t("This review format is not supported. Ask the operator to update the app."));
67 review = data;
68 history.replaceState(null, '', `/review/${year}`);
69 } catch (e) { if (current === request) error = e.message; }
70 finally { if (current === request) loading = false; }
71 }
72 async function bootstrap() {
73 loading = true; error = '';
74 try {
75 const config = await api('/api/config'); authMode = config.authMode;
76 const session = await api('/api/session'); user = session.user; years = session.years;
77 const pathYear = location.pathname.split('/').filter(Boolean)[1];
78 year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || '');
79 if (year) await loadReview();
80 } catch (e) { if (user || e.status !== 401) error = e.message; }
81 finally { loading = false; }
82 }
83 async function login(event) {
84 event.preventDefault(); signingIn = true; error = ''; notice = '';
85 try {
86 await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
87 password = ''; await bootstrap();
88 } catch (e) { error = e.message; }
89 finally { password = ''; signingIn = false; }
90 }
91 async function logout() {
92 notice = ''; error = '';
93 try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
94 catch (e) { notice = e.message; }
95 finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); }
96 }
97 // Route every navigation method through the same direction and boundary rules.
98 function goTo(index) {
99 const next = Math.max(0, Math.min(cards.length - 1, index));
100 if (next === slide) return;
101 swipeDirection = next > slide ? 1 : -1;
102 slide = next;
103 }
104 function navigate(delta) { goTo(slide + delta); }
105 function keydown(event) {
106 if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;
107 if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); }
108 if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); }
109 if (event.key === 'Home') { event.preventDefault(); goTo(0); }
110 if (event.key === 'End') { event.preventDefault(); goTo(cards.length - 1); }
111 }
112 function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; }
113 function pointerup(event) {
114 if (!touch) return;
115 const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null;
116 if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1);
117 }
118 onMount(bootstrap);
119</script>
120
121<svelte:head><title>{t("Jellyfin Year Review")}</title><meta name="description" content={t("Your private Jellyfin year in movies and television.")} /></svelte:head>
122<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} />
123
124<div class="app-shell" class:login-only={!user}>
125 {#if user}
126 <header class="topbar">
127 <a class="brand" href="/" aria-label={t("Jellyfin Year Review home")}><span class="brand-icon">{t("J")}</span><span>{t("Jellyfin")} <span class="brand-light">{t("Year Review")}</span><small>{t("A YEAR WORTH REPLAYING")}</small></span></a>
128 {#if user}
129 <div class="account">
130 {#if years.length > 1}<label class="year-picker">{t("Year")}<select aria-label={t("Review year")} bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if}
131 <button class="quiet" onclick={logout}>{t("Sign out")}</button>
132 </div>
133 {/if}
134 </header>
135 {/if}
136
137 <main>
138 {#if user}
139 {#if notice}<p class="message" role="status">{notice}</p>{/if}
140 {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>{t("Try again")}</button></div>{/if}
141 {/if}
142 {#if !user}
143 <section class="login-layout" aria-label={t("Sign in")}>
144 <form class="login-panel" onsubmit={login}>
145 <h1 style="white-space: pre-line">{t("Your year,\nreplayed.")}</h1><p>{t("Sign in with your existing Jellyfin account.")}</p>
146 {#if notice}<p class="message" role="status">{notice}</p>{/if}
147 {#if error}<div class="message error" role="alert">{error}</div>{/if}
148 <label for="username">{t("Jellyfin username")}</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} />
149 <label for="password">{t("Password")}</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} />
150 <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? t("Loading…") : signingIn ? t("Signing in…") : t("Roll the credits")} <span aria-hidden="true">↗</span></button>
151 <small class="login-note">{authMode === 'offline' ? t("Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.") : t("Your credentials are validated by your Jellyfin server. Your review stays private.")}</small>
152 </form>
153 </section>
154 {:else if loading}
155 <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>{t("Setting the scene…")}</h1><p role="status">{t("Loading your private review")}</p></section>
156 {:else if !years.length}
157 <section class="state-screen"><p class="eyebrow">{t("NOT QUITE SHOWTIME")}</p><h1>{t("Your story is on its way.")}</h1><p>{t("No review has been generated for your account. Ask your server operator to generate a year.")}</p><button class="primary" onclick={bootstrap}>{t("Check again")}</button></section>
158 {:else if review}
159 <div class="review-meta"><span>{review.year} / {user.name}</span></div>
160 <nav class="progress" aria-label={t("Review chapters")}>{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => goTo(i)}><span></span></button>{/each}</nav>
161 <section class="story tone-{slide % 4}" aria-roledescription={t("carousel")} aria-label={t("Your yearly review")} onpointerdown={pointerdown}>
162 <div class="story-orbit" aria-hidden="true"></div>
163 <div class="story-content" aria-live="polite" aria-atomic="true">
164 {#key card}
165 <div class="story-body" style:--swipe-from="{swipeDirection * 105}%">
166 <p class="eyebrow">{t("CHAPTER {number} / {name}", { number: String(slide + 1).padStart(2, '0'), name: cards[slide].name.toLocaleUpperCase(locale) })}</p>
167 {#if card === 0}
168 <span class="year-stamp">{review.year}</span><h1>{t("Your viewing recap")}</h1><p class="story-lede">{t(review.records ? "{name}, see the movies, TV shows, and viewing habits that made up your year." : "{name}, no viewing activity was recorded for this year.", { name: user.name })}</p><button class="primary" onclick={() => navigate(1)}>{t("View my recap")} <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span>
169 {:else if card === 1}
170 <h2>{t("Your watch time")}</h2><div class="big-stat">{hours(review.seconds)}<span>{t(review.availability.exactWatchTime.available ? "hours watched" : "hours watched · end-attributed estimate")}</span></div>
171 <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>{t("Movies")}</span></div><div><b>{duration(review.tvSeconds)}</b><span>{t("Television")}</span></div><div><b>{count(review.records)}</b><span>{t("Activity records")}</span></div><div><b>{count(review.uniqueTitles)}</b><span>{t("Unique movies & episodes")}</span></div></div>
172 {:else if card === 2 || card === 3}
173 {@const items = card === 2 ? review.movies : review.shows}
174 <h2>{card === 2 ? t("Movies watched") : t("TV shows watched")}</h2>
175 <p>{t("Everything you watched in {year}, grouped by month.", { year: review.year })}</p>
176 <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'movies' : 'shows'} />
177 {:else if card === 4}
178 <h2>{t("Unfinished movies")}</h2><p class="story-lede">{t("Movies where you watched less than {percent}% of the runtime.", { percent: review.rules.completionPercent })}</p>
179 {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{t("{percent}% of the movie watched", { percent: Math.round(item.completionPercent) })}</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? t("No unfinished movies found in supported records.") : t("Completion is unavailable without runtime and actual position ranges.")}</p>{/if}
180 {:else if card === 14}
181 <h2>{t("Shows you've watched are from platforms...")}</h2>
182 <p>{t("Distinct TV shows watched, grouped by platform.")}</p>
183 <PlatformBreakdown platforms={review.platforms ?? []} records={review.records} />
184 {:else if card === 5}
185 <h2>{t("Your top genres")}</h2><p>{t("Genres ranked by watch time.")}</p>
186 {#if review.genres.length}
187 {@const values = review.genres.slice(0, 7).map(v => v.seconds / 3600)}
188 {@const labels = review.genres.slice(0, 7).map(v => message(v.name))}
189 <Chart {values} {labels} metric={t("h")} />
190 <RadarChart {values} {labels} metric={t("h")} />
191 {:else}<p class="empty-note">{t("No genre history is available.")}</p>{/if}
192 {:else if card === 6}
193 <h2>{t("Viewing by hour")}</h2><p>{t("Your viewing activity across the hours of the day.")}</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} /></div>
194 {:else if card === 7}
195 <h2>{t("Viewing by weekday")}</h2><p>{t("Your viewing activity for each day of the week.")}</p><Chart values={[...review.weekdays.slice(1), review.weekdays[0]]} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
196 {:else if card === 8}
197 <h2>{t("Viewing by month")}</h2><p>{t("Your viewing activity for each month of {year}.", { year: review.year })}</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
198 {:else if card === 9}
199 <h2>{t("Your devices and apps")}</h2><p>{t("Your most-used devices and apps, ranked by watch time.")}</p><div class="two-columns"><div><h3>{t("Devices")}</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div><div><h3>{t("Apps & clients")}</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div></div>
200 {:else if card === 10}
201 <h2>{t("Your longest viewing session")}</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>{t("hours watched in your longest session")}</span></div><p class="story-lede">{t("{duration} elapsed · {records} records", { duration: duration(review.marathon.elapsedSeconds), records: count(review.marathon.records) })}</p><p>{new Date(review.marathon.start).toLocaleString(locale, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{message(review.availability.marathon.reason)}</p>{/if}
202 {:else if card === 11}
203 <h2>{t("Your viewing profile")}</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{message(review.personality.name)}</h3><p class="story-lede">{message(review.personality.explanation)}</p>{:else}<p class="empty-note">{message(review.availability.personality.reason)}</p>{/if}
204 {:else if card === 12}
205 <h2 class="ranking-title"><span>{t("It's not a competition...")}</span>{' '}<span class="ranking-subtitle">{t("but this is your watch-time ranking")}</span></h2>
206 {#if review.ranking}
207 <div class="ranking-highlights">
208 <div class="big-stat"><span class="ranking-value">#{review.ranking.rank}</span><span>{t("of {count} eligible viewers", { count: count(review.ranking.cohort) })}</span></div>
209 <div class="big-stat"><span class="ranking-value">{hours(review.seconds)} <small>{t("h")}</small></span><span>{t("Your total hours watched · movies & TV shows")}</span></div>
210 </div>
211 <RankingPosition ranking={review.ranking} />
212 <div class="stat-grid ranking-secondary"><div><b>{Math.round(review.ranking.percentile)}%</b><span>{t("Viewers with less watch time")}</span></div></div>
213 {:else}<p class="empty-note">{message(review.availability.ranking.reason)}</p>{/if}
214 {:else}
215 <p class="year-stamp">{t("SUMMARY / {year}", { year: review.year })}</p><h2>{t("Your year in summary")}</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>{t("Your watch time")}</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{message(review.personality?.name) || t("No viewing profile")}</b><span>{t("Your viewing profile")}</span></div>{/if}</div><p class="story-lede">{t("You have reached the end of your viewing recap.")}</p><button class="primary" onclick={() => goTo(0)}>{t("View recap again ↺")}</button>
216 {/if}
217 </div>
218 {/key}
219 </div>
220 <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label={t("Previous chapter")} onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label={t("Next chapter")} onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div>
221 </section>
222 <details class="source-notes"><summary>{t("Behind your review · source & availability")}</summary><p>{message(review.source)}</p><p>{message(review.coverage)}</p><p>{t("Snapshot: {date} · Format v{version}", { date: new Date(review.generatedAt).toISOString(), version: review.schemaVersion })}</p>{#each review.warnings as warning}<p>{message(warning)}</p>{/each}</details>
223 {:else}<section class="state-screen"><h1>{t("Let’s try that again.")}</h1><button class="primary" onclick={bootstrap}>{t("Reload review")}</button></section>{/if}
224 </main>
225 {#if user}<footer><span>{t("MADE FOR THE LOVE OF THE WATCH.")}</span></footer>{/if}
226</div>
227
228{#if user && !loading && review?.ranking && card === 12}<Confetti />{/if}